[DateRangeCalendar] Use Pointer Events for drag editing - #22279
Conversation
…events The hook used to run two parallel paths: HTML5 drag handlers for desktop and a custom touch path for mobile. The touch path predates iOS 15 native drag-and-drop support and is no longer necessary. Rely on drag events alone, with two tweaks lifted from Pragmatic Drag and Drop's element adapter so drag works on touch devices: - Always call `setData` on dragstart (iOS 15 silently swallows subsequent drag events otherwise). - Set both `draggingDate` (custom key, used by the same-date drop guard) and `text/plain` (Android Chrome will not fire `dragover`/`drop` without `text/plain` or `text/uri-list` in the dataTransfer). Drops support for iOS 14 and pre-Chromium Android, both of which never worked reliably with the touch fallback either. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Deploy previewhttps://deploy-preview-22279--material-ui-x.netlify.app/ Bundle size
Check out the code infra dashboard for more information about this PR. |
…press iOS Safari was intercepting the long-press on a draggable day as text- selection intent, never firing `dragstart`. The HTML `draggable="true"` attribute alone doesn't enable in-page drag on iOS — WebKit needs `-webkit-user-drag: element`, plus the text-selection UI suppressed so it doesn't race the drag intent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`touch-action: none` was added to suppress browser default touch handling during the custom touch-event drag path. With that path gone, it can prevent iOS WebKit from registering a long-press as drag intent, since the browser uses default touch behavior to detect the gesture. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t the drag iOS Safari aborts an in-flight HTML5 drag if the source element's DOM mutates during `dragstart` — and our handler was synchronously calling `setRangeDragDay`, `setIsDragging`, and `onDatePositionChange`, each re-rendering the day grid (toggling `data-position`, recomputing the dragging-range highlight, etc.) right inside the dragstart event. React Aria's `useDrag` waits a frame before flipping its dragging state for the same reason. Mirror that here by deferring the React state updates to `requestAnimationFrame`. To keep the synchronous gate that prevents `dragenter` / `dragover` / `drop` from acting outside an active drag (e.g. a stray external file drag), track an `isDraggingRef` alongside the state — set synchronously, read in the gate, and the React state is just for re-rendering. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without it, iOS Safari treats a quick swipe on a draggable day as page scroll instead of waiting for the long-press timer to confirm drag intent. With it, touches on draggable cells are committed to drag (after the native ~500ms long-press) and won't accidentally scroll the page. Touches on non-draggable cells are unaffected — only the isDayDraggable variant gets `touch-action: none`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Native HTML5 drag-and-drop on iOS Safari requires a ~500ms long-press gesture before `dragstart` fires (the browser's way of distinguishing drag from scroll). That's a fundamental property of the platform and can't be tweaked with CSS or rAF tricks — it shipped that way and stays that way. React Aria's `useMove` and the per-cell drag in their `useCalendarCell` both bypass native drag entirely on touch by using Pointer Events, which makes drag start as soon as the pointer leaves its initial position. Mirror that pattern here. The new flow: - `pointerdown` on a range-endpoint day starts the drag immediately (no delay, no long-press) and releases the implicit pointer capture so sibling cells can fire `pointerover` as the finger moves across the grid — same trick `usePress` uses. - `pointerover` on a cell during the drag updates the preview range. - A document-level `pointerup` listener commits the drop. - A document-level `touchmove` listener with `preventDefault` keeps the page from scrolling while the drag is in flight. - A capture-phase one-shot `click` suppressor prevents the synthesized click after a moved drag from re-entering the day's selection logic. - A no-op `onDragStart` cancels the browser's native drag so it doesn't draw a ghost on top of our pointer-driven gesture. The hook keeps its existing return shape semantically (`isDragging`, `rangeDragDay`, `draggingDatePosition`, plus event handlers to spread on cells) so `DateRangeCalendar` doesn't change. Tests use Pointer Events via `fireEvent.pointerDown` / `.pointerOver` / `.pointerUp`. `MockedDataTransfer` is no longer needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`handlePointerDown` was synchronously calling `onDatePositionChange` (and flipping `isDragging` / `rangeDragDay`) on every press of a range endpoint. That mutated `rangePosition` even for pure taps, and the click that followed the tap then routed into the wrong side of the range — breaking the e2e flow that taps an existing endpoint twice to collapse the range to a single day. The Android Chrome e2e "should allow re-selecting value to have the same start and end date" caught this. In the original HTML5-drag implementation, `dragstart` only fired after real movement, so taps were invisible to the hook. Mirror that: stash the source date / position / pointerId on `pointerdown`, but wait until `pointerover` reports a *different* cell before activating drag UI and notifying the parent of the source endpoint. A press without movement never touches React state or `rangePosition`, so the click handler runs with the calendar's natural state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR migrates DateRangeCalendar’s drag-to-edit interaction from native HTML5 drag-and-drop (plus a separate touch path) to a unified Pointer Events implementation, improving mobile UX (notably eliminating iOS Safari’s long-press drag delay).
Changes:
- Rewrote
useDragRangeto drive range endpoint dragging viapointerdown/pointeroverwith document-levelpointerupcommit handling and scroll suppression during the gesture. - Updated DateRangePickerDay draggable styling/behavior to align with the new pointer-driven gesture and avoid native side-effects (selection/callouts).
- Updated unit tests and test utilities to replay drags using pointer events instead of drag/touch events.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| test/utils/pickers/calendar.ts | Replaces drag-event test helpers with pointer-event drag replay helpers. |
| packages/x-date-pickers-pro/src/DateRangePickerDay/DateRangePickerDay.tsx | Adjusts draggable-day CSS to better support pointer dragging (no scrolling/selection callouts). |
| packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts | Core rewrite: pointer-driven drag tracking, preview updates on pointerover, and commit/cancel via document listeners. |
| packages/x-date-pickers-pro/src/DateRangeCalendar/DateRangeCalendar.test.tsx | Migrates drag tests from DataTransfer-based drag events to pointer events; removes obsolete touch-path tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ters A second pointerdown arriving while a drag is already in flight (multi-touch, pen joining a touch, second finger tap) would overwrite `pointerIdRef` and `cleanupListenersRef`, leaking the first gesture's document listeners and silencing its `pointerup` because the id check in the listener would no longer match. Bail early in that case so the original gesture stays in control. Two guards: `event.isPrimary === false` filters secondary multi-touch pointers up front, and `pointerIdRef.current != null` covers the case where some prior gesture is still considered active (also a recovery path if its pointerup was somehow lost). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The day cell button no longer needs the HTML `draggable="true"` attribute now that the drag is driven entirely by Pointer Events. The attribute was only there so the browser would render the cell as a drag source — which is exactly what we don't want anymore (the native ghost would draw on top of our pointer-driven gesture). The `draggable` prop on `DateRangePickerDay` stays accepted and still drives the `isDayDraggable` ownerState (and therefore the `cursor: grab` + `touch-action: none` + `user-select: none` styling). Only the DOM attribute pass-through is removed. Now that no native drag is initiated, the no-op `onDragStart` handler in `useDragRange` that existed solely to suppress the ghost is gone too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…handlePointerDown` The `event.isPrimary === false` check broke browser-mode tests: real `PointerEvent` constructed via `fireEvent.pointerDown(...)` defaults to `isPrimary: false` (the constructor's default), so the test event was short-circuited as if it were a secondary multi-touch pointer. jsdom doesn't have a real `PointerEvent` constructor so the property stayed `undefined` and the unit test passed — that's why CI caught it but local Vitest did not. The check was redundant: events are dispatched serially on a single JS thread, so by the time a second pointerdown reaches us, the first has already set `pointerIdRef`. The `pointerIdRef.current != null` check alone covers multi-touch, pen+touch, and the "stuck state" recovery scenario. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ests The `event.isPrimary === false` short-circuit in `handlePointerDown` is the right semantic guard against secondary multi-touch pointers — it matches what real browsers produce. The earlier removal was a workaround for `fireEvent.pointerDown` defaulting `isPrimary` to `false`, which the tests were silently relying on. The cleaner fix is to make tests mimic native behavior. Pass `isPrimary: true` from the `executeDateDrag` helper and from the inline child-element test, matching what a real first-finger touch / mouse press dispatches. Production keeps both guards: `isPrimary` filters secondary multi-touch up front, and `pointerIdRef.current != null` covers pen+touch (each pointer type has its own primary) and the "stuck state" recovery scenario. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trim the explanatory blocks added during the Pointer Events refactor. Same content, fewer words: the JSDoc on `getClosestElementWithDataAttribute` keeps the camelCase / kebab-case caveat (a real footgun); each handler keeps a one-or-two-line "why" without restating what the code obviously does. Net −22 lines. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
flaviendelangle
left a comment
There was a problem hiding this comment.
PR Review — mui/mui-x #22279
Title: [DateRangeCalendar] Use Pointer Events for drag editing
URL: #22279
Scope: 4 files, +178/−503 — full rewrite of useDragRange.ts, drops the parallel HTML5-drag + touch code paths, unifies under Pointer Events.
Reviewed via four parallel agents (code-reviewer, silent-failure-hunter, pr-test-analyzer, comment-analyzer), cross-referenced against the range-calendar branch on base-ui-plus which implements the same feature with a root-level pointer-capture + elementFromPoint architecture.
Files referenced below are paths within the PR repo (mui-x):
packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.tspackages/x-date-pickers-pro/src/DateRangeCalendar/DateRangeCalendar.test.tsxpackages/x-date-pickers-pro/src/DateRangePickerDay/DateRangePickerDay.tsxtest/utils/pickers/calendar.ts
Critical (must fix before merge)
The architectural root cause beneath all three: the hook treats pointerup as a reliable terminator, which it is not on touch/pen hardware. Cleanup is keyed off pointerId equality with no watchdog, no lostpointercapture, no recovery path.
C1. Unmount mid-drag leaves state stuck
useDragRange.ts:256-261. The useEffect cleanup only removes document listeners; it does not reset isDraggingRef, pointerIdRef, pendingDropRef, sourceDateRef. If the component re-mounts (month nav, parent re-render of a different cell tree) pointerIdRef.current != null is still true → the handlePointerDown guard at L126 rejects every subsequent gesture. Also keeps pendingDropRef.current.target (an unmounted DOM node) alive.
Fix: null all refs in the unmount cleanup.
React.useEffect(
() => () => {
cleanupListenersRef.current?.();
cleanupListenersRef.current = null;
pointerIdRef.current = null;
isDraggingRef.current = false;
sourceDateRef.current = null;
sourcePositionRef.current = null;
didMoveRef.current = false;
pendingDropRef.current = null;
},
[],
);C2. No watchdog for lost pointerup — permanent jam
useDragRange.ts:208-216, :126. If the OS swallows both pointerup and pointercancel for the active pointerId (documented iOS / WebView quirk under system gesture, page hide, capture steal), document listeners stay attached forever and the L126 guard refuses every future gesture. The comment at L124-125 names "recovery from a lost pointerup" as a goal of the pointerIdRef check, but the check only refuses recovery rather than enabling it.
Fix (preferred): on the L126 guard hit, proactively call cleanup() and start the new gesture — a fresh primary pointerdown definitionally ends the previous one. Reference branch sidesteps this entirely via setPointerCapture on the root + lostpointercapture.
C3. pointercancel after a real move silently discards the drop
useDragRange.ts:193-198. When the user has dragged to a new cell, pointercancel (iOS system gesture, context-menu long-press, scroll-snap interrupt) calls cleanup() which unwinds UI but never fires onDrop. From the user's perspective: range snaps back, no explanation, no recovery.
Fix: treat pointercancel after didMoveRef === true identically to pointerup and commit the drop, OR expose an onDragCancel(sourceDate, lastPreviewDate) callback. Spec intent of pointercancel is "UA interrupted the gesture, not the user".
Important
I1. Drag listeners bind to top-level document instead of ownerDocument
useDragRange.ts:181-184, :208-215. Breaks iframe-hosted pickers. The reference branch (RangeCalendarStore.handlePointerMove) routes everything through ownerDocument(this.rootRef.current).
Fix: const ownerDoc = event.currentTarget.ownerDocument ?? document; at the top of handlePointerDown, use for all four listeners and the click suppressor.
I2. No Escape-key cancellation while a drag is in progress
useDragRange.ts — useDragRangeEvents, no keydown handler at all. Native HTML5 DnD provided this for free; pointer-events implementations must wire it explicitly. Accessibility regression. Reference branch wires this in RangeCalendarStore.handleKeyDown.
Fix: add a document-level keydown listener in handlePointerDown that calls cleanup() on Escape.
I3. iOS text-selection magnifier not suppressed
useDragRange.ts:115-217. WebkitTouchCallout: none (DateRangePickerDay.tsx:228) handles the callout, but the iOS magnifier is a separate feature; the canonical fix is event.preventDefault() inside pointerdown. Reference impl does this.
Fix: add event.preventDefault() after the stopPropagation() at L145.
I4. releasePointerCapture unguarded against DOMException
useDragRange.ts:138-143. Safari 15 / some Android WebViews throw InvalidPointerId between the hasPointerCapture check and the releasePointerCapture call (non-atomic). The throw escapes handlePointerDown and the gesture silently fails to start. Reference branch wraps both calls in try/catch.
Fix: wrap in try/catch — it's a benign "already released" race.
I5. touchmove listener installed even on tap-without-move
useDragRange.ts:200-210. Non-passive touchmove on document is registered at pointerdown, before any movement. For every endpoint tap (very common — that's how you advance the range), the page loses compositor-thread scrolling until the user lifts.
Fix: defer the touchmove listener registration until didMoveRef flips to true inside handlePointerOver. The internal isDraggingRef check then becomes redundant.
I6. onDatePositionChange silently skipped if data-position missing
useDragRange.ts:244-248, :153-154. If a custom day-slot strips data-position, the cast (position as RangePosition | undefined) ?? null resolves to null, the dispatch is silently skipped, but setIsDragging(true) still fires — preview computes against the wrong endpoint with no diagnostic.
Fix: when sourcePositionRef.current is null at activation time, either abort drag entry or console.warn in dev mode.
I7. resolveDateFromTarget throws RangeError on non-numeric data-timestamp
useDragRange.ts:49-66. new Date(NaN).toISOString() throws; Number(non-numeric) is NaN. Throw mid-pointerover wedges the gesture and never reaches cleanup().
Fix: guard Number.isFinite(timestamp) before passing to adapter.date; return null otherwise.
I8. Two-listener race in synthetic-click suppressor
useDragRange.ts:176-184. Rapid back-to-back drags can leave two suppressClick closures racing on document; a stray unrelated click in the macrotask window can be silently consumed by the wrong gesture's suppressor.
Fix: track outstanding suppressors in a ref and replace, or rely on { once: true } + the setTimeout as belt-and-suspenders only.
Test gaps
| # | Behaviour | File:Line | Risk |
|---|---|---|---|
| T1 | Tap-vs-drag distinction (didMoveRef deferred activation) |
useDragRange.ts:156-158, :241-249 |
A "simplification" that moves setIsDragging(true) back into handlePointerDown would silently break tap-to-advance on touch; no test fails. |
| T2 | pointercancel cleanup |
useDragRange.ts:193-198 |
Forgetting the listener leaks pointerup/touchmove forever; no test fails. |
| T3 | Re-entrant pointerdown + non-primary rejection | useDragRange.ts:118, :126 |
Three guards (button > 0, isPrimary === false, pointerIdRef != null) all untested. |
| T4 | pointerover === currentTarget early-return |
useDragRange.ts:227 |
Removing the guard re-introduces redundant setRangeDragDay calls per pointer-over on the same cell — measurable perf regression. |
| T5 | Capture-phase click suppressor | useDragRange.ts:176-184 |
Current executeDateDrag never fires click, so the suppressor is never exercised. |
| T6 | touchmove.preventDefault on document |
useDragRange.ts:200-210 |
A "tidy-up" that moves the listener will silently re-enable scroll-while-drag on mobile. |
The PR's claim that "mouse, touch and pen flow through one code path so one test suite suffices" is true for the main handlePointerDown body, false for the touch-specific defenses (implicit capture release, document-level touchmove, jsdom hasPointerCapture guard). Deleting the touch-specific tests without adding behavioural assertions for these branches is the central testing concern.
Other test issues
- T7. The new "child elements" test is a near no-op —
DateRangeCalendar.test.tsx:247-273. React delegation suppliesevent.currentTarget = buttonregardless of which child the user touched, sogetClosestElementWithDataAttributeshort-circuits on the button itself and never walks. Either rewrite the production code to readevent.target, or rewrite the test to call the helper directly, or delete it. - T8.
executeDateDragfirespointerUpondocument—calendar.ts:24. Technically valid (production listens on document) but a regression that moves the listener back to the cell would silently keep passing in jsdom but break in real browsers. Consider firingpointerupon the last otherDate and letting it bubble. - T9. Missing
clientX/clientYon synthetic pointer events — ticking time-bomb if a drag-threshold is ever added. - T10.
pointerId: 1magic number duplicated incalendar.tsand the test file (instead of importing the helper constant). - T11. Repo-wide grep needed for stale
buildPickerDragInteractionscallers — public test-utility rename.
Comment issues
Inaccurate
useDragRange.ts:116-117— The> 0rationale is fabricated. jsdom'sMouseEventInit.buttondefaults to0, notundefined. The real reason to prefer> 0is that real browsers sendbutton: -1on events where no button changed state (e.g.pointeroverwhile a primary press is held);!== 0would falsely flag those. Rewrite around that.DateRangeCalendar.test.tsx:248-250— "The handler must walk up to the button to read its data attributes — exercise that path explicitly." The handler readsevent.currentTarget(the bound button), so no walking happens. Comment is rotted relative to the code it documents.useDragRange.ts:122-125— "ThepointerIdRefcheck also covers pen+touch (each pointer type has its own primary)" — slightly misleading: pen+touch is covered only bypointerIdRef, not by theisPrimaryhalf. Tighten.
Coupling / rot risk
useDragRange.ts:219-221— Drop the "testing-libraryfireEvent" half. The production rationale (React synthesizes enter/leave from over/out) is durable and sufficient.useDragRange.ts:156-158— ReferenceshandlePointerOverandrangePositionby name; neither will survive a refactor cleanly. Generic phrasing is fine.calendar.ts:5-11— Docstring aboveexecuteDateDragWithoutDropsays "...then pointerup", but that variant explicitly does not fire pointerup. Trim.
Good (keep)
The bulk of the comments do real work: the implicit-capture release rationale (L135-137), the cleanup re-render skip (L106), the swallow-click strategy (L172-175), the touch-action-isn't-enough explanation (L200-202), and the camelCase/kebab-case JSDoc on getClosestElementWithDataAttribute (L32-36) are all exactly the kind of comments that earn their keep in this file.
Strengths
- Sound state machine. Each ref has a single documented purpose; cleanup is centralised.
- Defer-drag-UI-until-first-move cleanly separates tap-on-endpoint from drag-from-endpoint — the comment at L156-158 documents this invariant.
pendingDropRef.current?.target === event.currentTargetearly-return prevents redundant state updates on repeatedpointerover.hasPointerCapturefeature-detect correctly handles jsdom.- Document-level
touchmove.preventDefaultcloses the iOS scroll-while-drag gap thattouch-action: nonealone leaves once the finger crosses cell boundaries. - Removed dead code (
emptyDragImgRef,MockedDataTransfer,resolveButtonElement,resolveElementFromTouch,rangeCalendarDayTouchescoord table) — net simplification. - No
as anycasts introduced. TheRangePositioncast is properly guarded with?? null. - Flip / reduce / expand happy paths and
shouldDisableDatemid-drag reactivity are well covered.
Recommended action plan
- Block on: C1, C2, C3 (critical state-machine bugs).
- Address before merge: I1 (
ownerDocument), I2 (Escape), I4 (releasePointerCapturetry/catch), I7 (Number.isFiniteguard). - Add tests: T1, T2, T3 minimum; T5/T6 strongly recommended. Decide T7 (rewrite vs. delete).
- Comment fixes: the three inaccurate items.
- Architectural note (not blocking): the reference
range-calendarbranch (base-ui-plus) uses root-levelsetPointerCapture+elementFromPoint, which sidesteps C2/C3 by design and getslostpointercaptureas a free recovery signal. If you're going to ship more pointer-driven gestures in mui-x pickers, that pattern is worth lifting wholesale rather than continuing per-hook cleanup orchestration. - Manual cross-browser smoke required: iOS Safari 15/16/17, Android Chrome. jsdom does not simulate pointer-capture semantics — the
releasePointerCapture+pointerover-on-siblings load-bearing assumption is unverifiable in CI.
Addresses the review on PR mui#22279. Treats `pointerup` as a possibly-unreliable terminator, adds the recovery paths the previous version was missing, and plugs several iframe / re-entrancy / parsing footguns. Critical - Unmount cleanup now nulls every gesture ref (not just listeners), so a remount or a mid-drag cell tree replacement can start fresh and detached DOM nodes referenced via `pendingDropRef` can be garbage-collected. - Re-entrant primary pointerdowns recover instead of refusing: a fresh primary pointer definitionally ends any prior gesture (covers pen+touch, covers a lost pointerup), so we `cleanup()` and continue rather than short-circuiting and jamming the hook permanently. - `pointercancel` after real movement now commits the drop. The spec intent is "UA interrupted, not the user"; the snap-back was otherwise silent and inexplicable. Important - All document-level listeners (`pointerup`, `pointercancel`, `keydown`, `touchmove`, click suppressor) now bind to `event.currentTarget.ownerDocument` so iframe-hosted pickers work. - Escape on `document` cancels an in-flight drag (accessibility parity with the native HTML5 drag we replaced). - `releasePointerCapture` is wrapped in try/catch — Safari 15 / some Android WebViews race between `hasPointerCapture` and the release call and throw `InvalidPointerId`. - `resolveDateFromTarget` guards `Number.isFinite(timestamp)` so a malformed `data-timestamp` returns null instead of throwing mid-`pointerover` and wedging the gesture. - The `touchmove`-blocks-scroll listener is now installed lazily on the first real move (not on every press), so pure endpoint taps don't disable compositor-thread scrolling. - The click suppressor tracks its outstanding teardown in a ref and tears the previous one down before installing a new one, so back-to-back drags can't race two suppressors on the document. - When `data-position` is missing on the source cell (custom day slot), activation aborts with a dev-mode warning instead of computing the preview against the wrong endpoint. Tests - `executeDateDrag` helper docstring no longer claims it fires pointerup. - Deleted the "child elements inside the day button" test — React delegation supplies `currentTarget = button`, so the walk-up helper short-circuits on the button itself; the test exercised nothing the other drag tests don't already cover. - New: secondary multi-touch rejection (`isPrimary === false`), fresh- primary stuck-state recovery, commit-on-pointercancel-after-move, no-commit-on-pointercancel-before-move, post-pointercancel hook still drag-capable, click suppression after a moved drag, Escape cancels an in-flight drag. Comment / docstring cleanup - `button > 0` rationale corrected: real browsers report `button: -1` on events where no button changed state. - The pen+touch coverage note moved off the `isPrimary` half (which doesn't cover it) onto the `pointerIdRef` recovery branch. - The `pointerover`-vs-`pointerenter` comment drops the testing-library footnote. - The "deferred to handlePointerOver" comment is now phrased generically so a rename doesn't rot it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
It only touches refs, so its closure is stable across renders. Wrapping gives it stable identity (matching the other handlers in this hook) and saves recreating the closure on every parent re-render. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ide any cell Restores the cancel-by-releasing-outside-any-target convention native HTML5 drag provides for free. Previously, if the user dragged onto a cell and then slid off the calendar before lifting, the gesture would silently commit the last cell they happened to hover — surprising on a release that the user likely intended as a cancel. Add `onPointerOut` to the per-cell handler set. When the pointer leaves a cell into something that isn't another cell (gap, header, outside the calendar), forget the would-drop target. If the pointer enters another cell next, that cell's `pointerover` re-sets the target; if it doesn't, `pointerup` sees null and skips the drop. Moving to a descendant of the cell (text span, etc.) is treated as still-inside and ignored. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…terup itself
Per-cell `onPointerOut` for the strict-cancel behavior was over-engineered.
Since we listen on `document` for pointerup and the pointer has its capture
released, the bubbled event already carries the actual element under the
pointer at release time. Walk up from `event.target` to find the day cell
(or null if released into a gap / off the calendar) and drop accordingly.
- Removed `handlePointerOut` and the `onPointerOut` slot prop. The day-cell
handler set is now just `onPointerDown` + `onPointerOver`.
- `pendingDropRef` collapsed into `lastHoveredCellRef`: it was tracking
two things (dedupe and would-drop), but now drop comes from the
pointerup event itself. The remaining ref is only used to (a) dedupe
`pointerover` within the same cell and (b) fall back as the drop
target on `pointercancel`, whose `event.target` is unreliable across
browsers.
- `finalizeGesture` now takes the event and a type tag ('pointerup' vs
'pointercancel') and resolves the drop accordingly.
- Test helper fires `pointerup` on the destination cell (it bubbles to
our document-level listener) instead of on `document` directly,
matching real-browser behavior.
- The "release outside any cell" test fires `pointerup` on
`document.body` — non-cell target → resolves to null → no drop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (2)
packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts:211
- The inline comment justifies
> 0(rather than!== 0) by stating "real browsers reportbutton: -1on events where no button changed state". That-1convention applies topointermove/pointerover, not topointerdown— apointerdownalways represents a button state change and will reportbutton >= 0. The reasoning given here is therefore inaccurate; the check is fine functionally (it accepts primary button = 0 and rejects secondary buttons), but the comment is misleading and worth tightening.
// Ignore secondary mouse buttons. `> 0` (not `!== 0`) is intentional:
// real browsers report `button: -1` on events where no button changed
// state, and we want to treat those as primary.
if (event.button > 0) {
packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts:262
event.currentTarget.datasetis read directly here, butresolveDateFromTarget(used inhandlePointerDownabove) still routes throughgetClosestElementWithDataAttribute. If anyone ever attachesonPointerDownfrom this hook to a wrapper that doesn't carrydata-positionon the same element that carriesdata-timestamp, drag will silently abort on first move becausesourcePositionRef.currentis unset. Since the previous implementation explicitly usedgetClosestElementWithDataAttribute(event.currentTarget, 'position')for resilience, consider keeping that traversal here too rather than assuming the dataset is oncurrentTargetdirectly.
const { position } = event.currentTarget.dataset;
sourcePositionRef.current = (position as RangePosition | undefined) ?? null;
Address review feedback on PR mui#22279. Material: `finalizeGesture` was committing whatever cell the `pointerup` landed on, including disabled `<button>` cells (Chromium and WebKit still fire pointerup on disabled buttons). `DateRangeCalendar.handleDrop` doesn't re-validate the date, so dragging an endpoint onto a `shouldDisableDate` / min-max / readOnly day produced an invalid range. Guard explicitly: skip `onDrop` when the resolved drop cell is a disabled `<button>`. Adds a regression test that drops on a `shouldDisableDate`-disabled cell and asserts no `onChange`. Comments: - The `> 0` rationale on the `event.button` guard claimed real browsers send `button: -1` on no-state-change events — true for `pointermove`/`pointerover`, but not `pointerdown`, which always reports a real button state change. The check is still correct (synthetic events that leave `button` unset should be treated as primary); the explanation is now accurate. - `event.currentTarget.dataset.position` is now read via `getClosestElementWithDataAttribute`, mirroring how `data-timestamp` is resolved. Resilient if a future slot puts `data-position` on a wrapper around the cell. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- **stopImmediatePropagation in click suppressor.** Capture-phase click
listeners registered on `document` by analytics, focus traps, or
third-party overlays would otherwise still observe the synthesized
post-drag click as if the user intentionally clicked the day cell.
- **Disabled-day guard no longer asserts the button cast.** Resolve the
focusable `<button>` separately from the `data-timestamp` host via
`.closest('button')` and `.querySelector('button')`. The disabled
check and focus() target now work whether `data-timestamp` lives on
the button itself (today) or on a future wrapper around it.
- **Eager `touchmove` for touch pointers.** Previously installed lazily
on first cross-cell move, relying on the spec's `touch-action`
latching behavior to suppress scroll until then. Real-world
WebKit/Chromium versions don't reliably honor the latch. Attach
eagerly when `pointerType === 'touch'`; mouse/pen don't need it.
- **Escape `preventDefault` gated on `didMoveRef`.** A press without
movement is indistinguishable from a tap; let Escape propagate so a
host modal/popover can close on the same key. Only consume Escape
when there's a visible drag in flight.
- **Removed `event.stopPropagation()` from `handlePointerDown`.** It was
defensive but unnecessary — no nested drag handlers exist in the
calendar tree that would collide. Bubble now flows naturally to
ButtonBase's ripple and any parent listeners.
- **Removed unused `ownerDocumentRef`** (only the lazy `touchmove`
install read it).
- **Test:** verify `onRangePositionChange` fires on the first
cross-cell move with the source endpoint, and *only* on the first
move (the position handoff is what makes range flip work; the old
touch path covered it implicitly).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (4)
packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts:210
- When the pointer is released after movement onto a disabled day or outside any cell,
wasMovedis true soinstallClickSuppressoris still installed even though no drop was committed. The suppressor will then swallow the very next click anywhere in the document (within the same task tick before thesetTimeout(teardown, 0)runs). For releases on disabled day buttons this is largely benign (disabled buttons don't fire click in browsers), but for releases outside the calendar entirely (e.g., ondocument.bodyor some sibling UI element), a legitimate post-release click on a different unrelated element could be swallowed. Consider gatinginstallClickSuppressoron whether a commit actually occurred (or at minimum, only whendropCellwas a real day cell), so a canceled gesture doesn't interfere with user interactions outside the calendar.
if (eventType === 'pointerup' && wasMoved) {
// The click that follows pointerup would re-enter the day's selection
// logic and undo the drop; swallow it. (Not needed on pointercancel —
// no click follows a canceled gesture.)
installClickSuppressor(ownerDoc);
}
packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts:407
- On unmount,
clearGestureStateruns butclickSuppressorRefis not torn down. If the component unmounts in the narrow window betweenpointerup(which installs the suppressor) and the synthesizedclick/setTimeout(0)(which clears it), the capture-phase click listener remains attached todocumentreferencing closures over the now-unmounted hook scope. The leak is short-lived (setTimeout(0)still fires) but it can also unintentionally swallow a click on unrelated UI that the parent navigates to after unmount. Consider tearing downclickSuppressorRef.current?.()in the unmount effect as well.
// On unmount, clear gesture state so a remount can start fresh and any
// detached DOM nodes still referenced by gesture refs can be GC'd.
// `clearGestureState` is `useEventCallback`-stable, so the effect runs once.
React.useEffect(() => () => clearGestureState(), [clearGestureState]);
packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts:332
- The Escape handler always calls
cleanup()even for idle presses (no movement), but onlypreventDefault()s for moved gestures. This means: if a user presses-and-holds on an endpoint cell (no drag yet) and the host modal/popover has an Escape-to-close handler, both the modal will close AND the in-flight press is silently torn down — including removal of thepointerup/pointercanceldocument listeners. The eventualpointerupthen becomes a no-op for the hook, but the synthesized post-pointerup click still propagates normally (no suppressor was installed), so the day's tap-to-advance still fires. This may be intentional, but the consequence is that pressing Escape during an idle press leaves the gesture state half-collapsed (gesture listeners gone, but click logic still runs); please confirm this is the desired UX, and consider whether the idle-press case should leave the gesture alone entirely until pointerup.
const onKeyDown = (keyEvent: KeyboardEvent) => {
if (keyEvent.key !== 'Escape') {
return;
}
// Only consume Escape when there's a visible drag in flight. A press
// without movement is indistinguishable from a tap; let Escape
// propagate so a host modal/popover can still close on the same key.
if (didMoveRef.current) {
keyEvent.preventDefault();
}
cleanup();
};
packages/x-date-pickers-pro/src/DateRangeCalendar/useDragRange.ts:242
event.button > 0accepts negative values such as-1. The Pointer Events spec usesbutton: -1to indicate "no button changed state" forpointermove/pointeroveretc., and some test environments / synthetic events may set this onpointerdownas well. The comment says the permissive check is to allow synthetic events withbuttonunset (undefined > 0isfalse, OK), but-1 > 0is alsofalse, so apointerdownwithbutton: -1would be treated as primary. Considerevent.button !== 0 && event.button != nullor explicit allow-list to be more defensive, since middle/right clicks withbutton: 1/2are correctly rejected today butbutton: -1is silently treated as a primary press.
// Ignore secondary mouse buttons (middle = 1, right = 2). `> 0` rather
// than `!== 0` keeps the gesture permissive when `event.button` is left
// unset by a synthetic event (some test environments).
if (event.button > 0) {
return;
}
siriwatknp
left a comment
There was a problem hiding this comment.
👍 Looks good to me overall. The Pointer Events state machine is clear and the new tests cover the important edge cases. One small test coverage point:
(1) The disableDragEditing test (and the draggable attribute parts of the readOnly / disabled tests) no longer verifies behavior — draggable is removed before reaching the DOM.
…h real drag attempts The `disableDragEditing` test and the `readOnly` / `disabled` tests were asserting that selected day cells did not carry the `draggable` HTML attribute. After this PR removed the attribute forwarding entirely (no day cell carries `draggable` regardless of state), those checks pass trivially and prove nothing. Replace each assertion with a real drag attempt that confirms `onChange` is not called. `disabled` / `readOnly` cascade into `shouldDisableDragEditing` inside the calendar, so the gesture must short-circuit at `handlePointerDown` and never reach `onDrop`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address useful subset of Copilot review #4291310693: - **Click suppressor gated on `dropCell`.** Previously installed on every `wasMoved` pointerup, including releases outside the calendar. With `stopImmediatePropagation` that could swallow a legitimate click on unrelated host UI. Install only when the release lands on a real day cell — covers the "drag returned to source" case (where suppression is genuinely needed to stop the click from rewriting the range), but no longer interferes when the user releases off the calendar. - **`clickSuppressorRef` torn down in `clearGestureState`.** Without this, an unmount in the narrow window between `pointerup` and the `setTimeout(0)` teardown would leave a capture-phase listener on `document` long enough to swallow a click on whatever UI the parent navigates to. - **Escape on idle press is a no-op for the hook.** Previously we cleaned up listeners even when there was no movement yet, but never suppressed the subsequent click — leaving the gesture half-collapsed (hook listeners gone, tap-to-advance still firing on release). Now Escape only consumes and cancels when a visible drag is in flight; an idle press is left alone and behaves as a tap on release. Skipped Copilot's `event.button === -1` defensiveness suggestion: per spec, `pointerdown` always reports a real button state change and never fires with `button: -1`. The earlier Copilot review on this same PR confirmed the point. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes two Copilot review coverage gaps:
- Two tests fire `pointerDown` with `pointerType: 'touch'` and assert
the document-level `touchmove` listener calls `preventDefault` (page
scroll suppression). A companion test with `pointerType: 'mouse'`
asserts the listener is *not* attached for mouse pointers.
- One test releases `pointerup` on a synthesized child element appended
inside a day button — exercises the `getClosestElementWithDataAttribute`
/ `.closest('button')` walk in `finalizeGesture` that resolves the
drop target when the pointer lands on a child node (TouchRipple span,
text content).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Drag-to-edit on the range start/end day was implemented with native HTML5 drag-and-drop, with a parallel touch-event path layered on top to make it usable on mobile. On iOS Safari, the drag UX was poor: native
dragstartrequires a ~500ms long-press to distinguish from scroll, and that delay is fixed by the platform — no CSS orsetDatatweak shortens it.This PR replaces the entire drag mechanism with Pointer Events, so mouse, touch, and pen all flow through one code path. Tap-and-drag on iOS is now immediate (no long-press), matching the feel of React Aria's useMove / useCalendarCell.
How it works
pointerdownon a range-endpoint day starts tracking the gesture and releases the implicit touch pointer-capture, so subsequentpointeroverevents fire on the cells the finger crosses (same trickusePressuses).pointeroveron a different cell flips the drag state on, notifies the parent of the source endpoint viaonDatePositionChange, and starts updating the preview range. Until then, the press is indistinguishable from a tap —rangePositionstays untouched so the regular click handler can advance it normally.pointeruplistener resolves the drop target fromevent.target(the actual element under the pointer at release) and commits, unless the target isn't a day cell (released into a gap or off the calendar → cancel) or the day is disabled.pointercancellistener treats UA-interrupted gestures after real movement as a commit (spec intent ofpointercancel), with the last hovered cell as the drop target.keydownlistener cancels on Escape — only consuming the key when a visible drag is in flight, so host modals/popovers can still close on Escape during an idle press.touchmovelistener is registered on the owner document to suppress page scroll once the finger crosses cell boundaries (touch-action: noneon the source cell alone isn't enough). Mouse/pen don't need it.clicksuppressor withstopImmediatePropagationprevents the synthesized post-pointerup click from re-entering the day's selection logic and undoing the drop.What changed
useDragRange.ts— full rewrite around Pointer Events. Re-entrancy guards, owner-document listener binding,pointercancelrecovery, disabled-day guard, malformed-data-timestampguard,Escapecancellation, and per-touch eager scroll suppression.DateRangePickerDay.tsx— stop forwarding thedraggableprop to the underlying DOM element. The prop continues to drive thecursor: grabstyling viaisDayDraggableownerState; the HTML attribute is no longer needed since we don't use native HTML5 drag.DateRangeCalendar.test.tsx— drag tests fire pointer events instead of drag events.MockedDataTransferis no longer needed. New tests cover multi-touch rejection, stuck-state recovery, pointercancel commit-after-move, post-cancel re-entrancy, click suppression, Escape cancellation, release-outside-any-cell cancellation, disabled-day rejection, and the first-move position handoff.test/utils/pickers/calendar.ts—executeDateDragandexecuteDateDragWithoutDropdrive cells withpointerDown/pointerOver/pointerUpand nodataTransfer. Public test-helper API unchanged.The internal hook signature changed (returns
onPointerDown+onPointerOver, no moreonDragStart/onDragEnter/onDropetc.). The hook is internal so this is a non-breaking change for consumers.Test plan
pnpm typescript— cleanpnpm eslint— clean on changed filespnpm test:unit --project x-date-pickers-pro(UTC) — 35 passed, baseline 5 pre-existing local-timezone flakes onlypnpm test:browser --project x-date-pickers-pro(real Chromium) — all 15 dragging tests pass🤖 Generated with Claude Code